# 👉 diff 算法
diff 算法是通过同层之间的树节点进行比较,时间复杂度只有 O(n), 算是一个较高效的算法。
# why diff
数据变化,vue 如何更新节点
会先根据真实的 DOM 节点结构生成 virtual DOM,当 virtual DOM 某个节点的数据改变后,会生成一个新虚拟节点 vnode。
触发 set 方法后,消息发布器 Dep 调用 Dep.notify 通知所有订阅者 watcher,订阅者的回调函数
vm.update(vm.render())
,vm.render()
就是生成的新 vnode,vm.update()
就会带着新 vnode,和旧虚拟节点 oldVnode 作对比,给真实的 DOM 打补丁,最后更新相应的视图。virtual DOM 和真实 DOM 区别
使用 document.CreateElement 和 document.CreateTextNode 创建的就是真实节点。
virtual DOM 是将真实的 DOM 的数据抽取出来,以对象的形式模拟树形结构:
<div id="test" class="div1"> <p class="p1"></p> <p class="p2"></p> </div>
对应以下虚拟 DOM 对象
const vnode = { el: div, //对真实的节点的引用,本例中对应document.querySelector('#test.div1') tag: "div", //节点的标签 sel: "div#test.div1", //节点的选择器 data: null, // 一个存储节点属性的对象,对应节点的el[prop]属性,例如onclick , style text: null, //如果是文本节点,对应文本节点的textContent,否则为null children: [ { el: p, tag: "p", sel: "p.p1", data: null, text: null, children: [], }, { el: p, tag: "p", sel: "p.p2", data: null, text: null, children: [], }, ], //存储子节点的数组,每个子节点也是 vnode 结构 };
virtual DOM 本质是在 JS 操控 DOM 的过程中进行一个缓存。使用真实 DOM 对更新的数据重绘整个视图层是相当消耗性能,通过虚拟 DOM 和 diff 算法得出一些需要修改的最小单位,并对小单位的位图作出对应的更新,这样子可以减少不必要的 DOM 操作,以此来提高性能。
diff 比较方式
diff 通过 patch 函数像打补丁一样修改真实 DOM。比较只会在同层级进行, 不会跨层级比较。
Vue2.0 diff 过程简述:
1) 通过patch
对同级进行比较,然后再往下比较子节点;
首先会通过sameNode
判断两个节点的 key、tag、isComment、data
同时定义或不定义以及当标签类型为 input 的时候的 type
相不相同来确定两个节点是不是相同的节点:
如果不是的话就将新节点替换旧节点;
如果是相同节点的话才会进入到 patchVnode
阶段。
2)patchVnode
阶段中,会有几种情况的判断:
a. 判断节点引用是否完全一致,是则直接 return;
b. 然后会对文本节点进行判断,如果是文本节点并且不相等,那么将真实 DOM 对应的文本节点设置为 Vnode 的文本节点;
c. 然后再判断一方有子节点和一方没有子节点的情况:
如果旧的一方没有而新一方有子节点,则添加创建并添加新的子节点;
如果旧的一方有而新一方没有子节点,则把旧子节点删除;
d. 最后是判断双方都存在子节点的情况,是则执行 updateChildren 操作。
3)通过 updateChildren
来更新子节点:
在这个阶段核心是采用双端比较
的算法,同时从新旧节点的两端进行比较。
在这个过程中,会用到模版编译时的静态标记配合 key 来跳过对比静态节点(没有涉及 data 数据{{}})。
# patch
diff 的过程就是调用 patch 函数,就像打补丁一样修改真实 dom。
patch 函数有两个参数,vnode 和 oldVnode,也就是新旧两个虚拟节点。
function patch(oldVnode, vnode) {
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode);
} else {
// 当前oldVnode对应的真实元素节点
const oEl = oldVnode.el;
// 父元素
let parentEle = api.parentNode(oEl);
// 根据Vnode生成新元素
createEle(vnode);
if (parentEle !== null) {
// 将新元素添加进父元素
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl));
// 移除以前的旧元素节点
api.removeChild(parentEle, oldVnode.el);
oldVnode = null;
}
}
// patch最后会返回vnode,vnode和进入patch之前的不同在哪
return vnode;
}
# sameNode
/*
判断两个VNode节点是否是同一个节点,需要满足以下条件:
key相同
tag相同(当前节点的标签名)
isComment相同(是否为注释节点)
是否data都有定义(当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息)
当标签是<input>时,type是否相同
*/
function sameVnode(a, b) {
return (
a.key === b.key &&
a.tag === b.tag &&
a.isComment == b.isComment &&
isDef(a.data) == isDef(b.data) &&
sameInputType(a, b)
);
}
/*
判断当标签是<input>的时候,type是否相同
某些浏览器不支持动态修改<input>类型,所以他们被视为不同类型
*/
function sameInputType(a, b) {
if (a.tag !== "input") return true;
let i;
const typeA = isDef((i = a.data)) && isDef((i = i.attrs)) && i.type;
const typeB = isDef((i = b.data)) && isDef((i = i.attrs)) && i.type;
return typeA === typeB;
}
# patchNode
当 oldVnode 和 Vnode 值得比较后,会对两个节点执行 patchNode:
function patchVnode(oldVnode, vnode) {
// 引用一致则直接返回
if (oldVnode === vnode) return;
// 指向对应的真实DOM
// vnode表示新节点,此时是没有elm属性的。而在经过createElm方法后,vnode.children中的子节点都有了elm属性,此时只有vnode没有elm属性,而能进到 patchVnode 方法来的新旧节点,一定经过了sameVnode方法的判断,说明他们节点本身几乎一样,所以新节点可以用旧节点的elm
const el = (vnode.el = oldVnode.el);
let i;
let oldCh = oldVnode.children;
let ch = vnode.children;
// 如果他们都有文本节点并且不相等,那么将el的文本节点设置为vnode的文本节点
if (
oldVnode.text !== null &&
vnode.text !== null &&
oldVnode.text !== vnode.text
) {
// node.textContent = vnode.text
api.setTextContent(el, vnode.text);
} else {
updateEle(el, vnode, oldVnode);
// 如果oldVnode和vnode都有子节点,则执行updateChildren,对子节点进行比较(diff核心)
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch);
}
// 如果oldVnode没有子节点,而vnode有,则将vode的子节点真实化并新增至el
else if (ch) {
createEle(vnode);
}
// 如果oldVnode有子节点,而vnode没有,则删除el的子节点
else if (oldCh) {
api.removeChildren(el);
}
}
}
# updateChildren
function updateChildren(parentElm, oldCh, newCh) {
// 提取 oldVNode 子节点的头尾Idx以及节点内容,作为移动指针
let oldStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
// 提取 newVNode 子节点的头尾Idx以及节点内容,作为移动指针
let newStartIdx = 0;
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
// 存储所有旧子节点的Key -> index的哈希表
let oldKeyToIdx;
// 新节点匹配旧子节点中的index
let idxInOld;
let elmToMove;
let before;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 边界情况处理
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEnIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartVnode];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
}
// 前四种情况是在有指定key的时候,分别比较oldCh以及newCh的两头节点 2*2=4 种情况。若判定为同一个Vnode则直接patchVnode。
// 对于 sameVnode(oldStartVnode, newStartVnode) 和 sameVnode(oldEndVnode,newEndVnode) 为 true 的情况,不需要对DOM进行移动
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
// 匹配上的两个指针向中间移动
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode);
// vnode moved right
//若oldStartVnode和newEndVnode匹配上了,说明真实DOM的第一个子节点会被移动最后
api.insertBefore(
parentElm,
oldStartVnode.el,
api.nextSibling(oldEndVnode.el)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
// vnode moved left
// 若oldEndVnode和newStartVnode匹配上了,说明真实DOM的最后子节点会被移动最前
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
// 如果四种比较都没匹配成功,接下来的比较会分支两种情况:
// 如果设置了key,就会用key进行比较,根据所有旧子节点的key生成哈希表,key为oldVnode的key,value为对应index序列的哈希表(所有旧子节点的 key 映射到旧节点下标)。
else {
// 所有旧子节点的 key 映射到旧节点下标,然后用新vnode的key去找出在旧节点中可以复用的位置。
// 只有第一次进来undefined的时候会生成,也为后面检测重复的key值做铺垫
if (oldKeyToIdx === undefined) {
// 比如oldCh是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}],oldStartIdx = 0,oldEndIdx = 2
// 生成 {key0: 0, key1: 1, key2: 2}
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 在旧节点中匹配可复用的位置
idxInOld = oldKeyToIdx[newStartVnode.key];
// newStartVnode没有key 或 该key没有在老节点中匹配上,则创建一个新的节点插入至oldStartVnode对应位置
if (!idxInOld) {
api.insertBefore(
parentElm,
createEle(newStartVnode).el,
oldStartVnode.el
);
newStartVnode = newCh[++newStartIdx];
} else {
// 匹配上了则获取同key的旧子节点
elmToMove = oldCh[idxInOld];
// 相同key,还要判断和匹配节点是否为sameVnode
if (elmToMove.sel !== newStartVnode.sel) {
// 相同key但不是sameVnode的时候,创建新节点并插入至oldStartVnode.el的前边。
api.insertBefore(
parentElm,
createEle(newStartVnode).el,
oldStartVnode.el
);
} else {
// 相同key且是sameVnode的时候,则将新节点插入,同时往下patchVnode,并把匹配上的旧子节点设置为null
pathVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = null;
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el);
}
newStartVnode = newCh[++newStartIdx];
}
}
}
// 在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一个已经遍历完了,就会结束比较。
if (oldStartIdx > oldEndIdx) {
// oldCh先遍历完,则证明剩下的newCh节点是新增的(newStartIdx和newEndIdx之间的vnode),调用addVnodes将其拆入至before后面
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
// newCh先遍历完,则在真实DOM中将[oldStartIdx, oldEndIdx]的多余节点删除
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}